Skip to content

fix: prevent stale SDK session and tool-marker parroting after idle timeout#184

Merged
op7418 merged 1 commit intoop7418:mainfrom
jasonjaclyn2017:fix/stale-sdk-session-after-idle-timeout
Mar 7, 2026
Merged

fix: prevent stale SDK session and tool-marker parroting after idle timeout#184
op7418 merged 1 commit intoop7418:mainfrom
jasonjaclyn2017:fix/stale-sdk-session-after-idle-timeout

Conversation

@jasonjaclyn2017
Copy link
Copy Markdown
Contributor

Summary

After a stream idle timeout (~330s), Claude's responses would contain raw tool markers like [Used tool: Read] and [Tool result: ...] as plain text instead of actually executing tools. This made the conversation unusable — the user had to start a new session to recover.

Root cause

Three problems combined to produce this bug:

  1. Stale session ID persists after timeout: When the idle timeout fires, the client-side AbortController kills the stream, but the sdk_session_id stays in the SQLite database. The next user message reads this stale ID and tries to resume the dead remote session.

  2. Resume fails silently into a bad fallback: The resume attempt fails (the remote session is gone), so claude-client.ts catches the error and falls back to buildPromptWithHistory(), which reconstructs conversation context from the local message database.

  3. History fallback produces parrot-able text: buildPromptWithHistory() converted tool-use and tool-result content blocks into plain-text markers like [Used tool: Read] and [Tool result: file contents here...]. Claude interpreted these as text it had previously output and reproduced them verbatim in new responses.

Fix (3 changes)

src/app/api/chat/sessions/[id]/route.ts — Add sdk_session_id support to the existing PATCH endpoint so it can be cleared via API call.

src/lib/stream-session-manager.ts — In the idle timeout handler, after emitting the error event, fire a PATCH request to clear the stale sdk_session_id from the database. This ensures the next message starts a fresh SDK session instead of attempting a doomed resume.

src/lib/claude-client.ts — Rewrite buildPromptWithHistory() to strip tool_use and tool_result blocks entirely instead of converting them to [Used tool: ...] / [Tool result: ...] text. Now only the actual text content from assistant turns is preserved, with an explicit preamble telling Claude the history is a summary of already-executed turns that should not be repeated.

How it works after the fix

Idle timeout fires
  → stream-session-manager PATCHes sdk_session_id = '' in the DB
  → next user message sees empty sdk_session_id
  → shouldResume = false, starts a fresh SDK session
  → buildPromptWithHistory provides clean text-only context
  → Claude responds normally with real tool execution

Files changed

File Change
src/app/api/chat/sessions/[id]/route.ts Accept sdk_session_id in PATCH body
src/lib/stream-session-manager.ts Clear sdk_session_id on idle timeout
src/lib/claude-client.ts Strip tool blocks from history fallback

Test plan

  • Start a conversation with tool usage, let it idle timeout (or temporarily set STREAM_IDLE_TIMEOUT_MS to 10s)
  • Send a new message after timeout — Claude should start a fresh session and execute tools normally, no [Used tool: ...] text artifacts
  • Normal conversations (no timeout) should be completely unaffected
  • npm run test passes (typecheck + unit tests)

🤖 Generated with Claude Code

…imeout

After a stream idle timeout (330s), the SDK session ID remained in the
database. The next user message attempted to resume this broken session,
which failed and fell back to buildPromptWithHistory(). That function
converted tool-use blocks into [Used tool: ...] and [Tool result: ...]
plain-text markers, which Claude then parroted back as literal text
instead of executing real tools.

Three changes fix this end-to-end:

1. session PATCH endpoint (route.ts): accept sdk_session_id in the
   request body so it can be cleared via API.

2. idle timeout handler (stream-session-manager.ts): after emitting
   the timeout error, fire a PATCH request to clear the stale
   sdk_session_id from the database. This ensures the next message
   starts a fresh SDK session instead of attempting a doomed resume.

3. history fallback (claude-client.ts): strip tool_use and tool_result
   blocks from buildPromptWithHistory() output entirely. Previously
   these were rendered as [Used tool: X] / [Tool result: ...] text
   that Claude treated as its own output. Now only the text portions
   of assistant turns are included, with an explicit instruction that
   the history is a summary of already-executed turns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@op7418 op7418 merged commit a53c605 into op7418:main Mar 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants